<?php
/**
 * RIA JavaScript Classes Integrator
 * for PHP-Based Site Engines
 * 
�*�Build all-in-one, compress, gzip JavaScript-Classes file from 
 * Class-based js-sources (for example MooTools Classes)
 * Requered JSMin.php (PHP port by Ryan Grove <ryan@wonko.com>)
 *  
 * PHP 5 or higher is required.
 * 
 * Classes & classes files must be named and 
 * stored Zend-Framework style
 * http://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.classes
 * For example:
 * Ria_Ajax_Test must be poited to 
 * Ria/Ajax/Test.js file
 * 
 * If class required another clesses in comment line
 * make directive '@required ClassName'. 
 * For example:
 * @required   Ria_Ajax_Test
 * 
 * @example
 *  
 * require ('../libs/JSMin.php');
 * require ('../libs/JsClasses.php');
 *
 * $jsClasses = new JsClasses();
 * $jsClasses->setClassesPath('../js');
 * $jsClasses->setClassesCacheFile('../cache/jsClasses.cache');
 * $jsClasses->make(array('Ria_JsonRequest_Test'));
 * $classesResultJsFileName = $jsClasses->getJsFileName();
 *
 * @copyright  2008 IT RIA
 * @license    GNU LGPL
 * @version    1.2beta (2008-10-09)
 * @author     Oleg.Cherniy@gmail.com
 * @link       http://linux.ria.ua/JsClasses/  
�*/

class JsClasses 
{
	/**
	 * Array of directory path for JavaScript Classes
	 * 
	 * @var array
	 */
	protected $_classesPath = array(
		'../application/classes/js',
		'../../core2/js'
	);
	
	/**
	 * 	The filename, which will be made local redirect	
	 *  
	 * @since available since Release 1.1
	 * @var string
	 */
	private $_jsRewriteFileName = 'jsClasses';
	
	/**
	 * The file extension of $_jsRewriteFileName
	 * 	
	 * @var string
	 */
	private $_jsRewriteFileExt = 'js';
	
	/**
	 * Temporary directory for cache file
	 *
	 * @var string
	 */
	private $_classesCacheDir = '../application/templates_c/cache';

	/**
	 * Cache file extension
	 *
	 * @var string
	 */
	private $_classesCacheExt = 'jscache';
	
	/**
	 * Directory for generating Cache JavaScript files
	 * 
	 * @var string
	 */
	private $_jsFilesDir = 'js/pack';
	
	/**
	 * Is use JavaScript minifier for result JavaScript Classes file 
	 * by JSMin http://code.google.com/p/jsmin-php/
	 *
	 * @var boolean
	 */
	private $_compressMode = true;

	/**
	 * Make Gzip copy of result JavaScript-file 
	 * for gzip-support browsers with '.jz.gz' extension
	 * 
	 * For use this feature in Apache make in $_jsFilesDir
	 * .htaccess file with text: 
	 *  
	 * RewriteEngine On
	 * RewriteRule (jsClasses)_(.*)\.js$ $1.js
	 *
	 * # Remove lines below if your apache don't support mod_headers
	 *
	 * <FilesMatch "\.js\.gz$">
	 * ForceType text/javascript
	 * Header set Content-Encoding: gzip
	 * </FilesMatch>
	 * <FilesMatch "jsClasses\.js$">
	 * RewriteEngine On
	 * RewriteCond %{HTTP:Accept-Encoding} gzip [OR]
	 * RewriteCond %{HTTP:TE} gzip
	 * RewriteCond %{REQUEST_FILENAME}.gz -f
	 * RewriteRule (.*)\.js$ $1\.js.gz [L]
	 * ForceType text/javascript
	 * </FilesMatch>
	 * 
	 * @var boolean
	 */
	private $_gzipFileMode = true;
	
	
/**#@+
 * END JsClasses Configuration Section
 * There should be no need to touch anything below this line.
 */

	/**
	 * Array of JavaScript Classes without '.js' extension
	 *
	 * @var array
	 */
	private $_classes = array();
	
	/**
	 * Array of classes cache data
	 *
	 * @var array
	 */
	private $_classesCacheData = array();
	
	/**
	 * Is time modify class checking
	 *
	 * @var array
	 */
	private $_checkClassModifyTime = true;

	/**
	 * Is cache rebuils required
	 *
	 * @var boolean
	 */
	private $_isCacheRebuildRequired = false;

	/**
	 * Cache JavaScript file
	 *
	 * @var string
	 */
	private $_jsFileName;
	
/**#@-*/	
	
	
	/**
	 * JsClasses Constructor
	 *
	 */
	public function __construct() {}

	/**
	 * make JS Classes
	 *
	 * @param array $classes array of JSClasses without '.js' extension. 
	 * @example 
	 *   new JsClasses(array('Ria_JsonRequest','RIA_HtmlRequest'));  
	 * 	 */
	public function make($classes) {
		$this->_loadClassesCache();
		$this->_parseClasses($classes);
		$this->_checkClasses();
		if (! file_exists($this->_getJsRealFileName())) {
			$this->_isCacheRebuildRequired = true;
		}
		if ($this->_isCacheRebuildRequired) {
			$this->_generateJsFileName();
			$this->_generateJsFile();
			$this->_generateJSCache();
			if ($this->_compressMode) {
				$this->_makeCompressedJsFile();
			}
			if ($this->_gzipFileMode) {
				$this->_gzipJsFile();
			}
		}
	}

	/**
	 * Get path for Virtual Cache JavaScript file
	 * from root web-server directory
	 *
	 * @return string
	 */
	public function getJsFileName() {
		return $this->_jsFilesDir . '/' .$this->_jsFileName;
	}
	
    /**
     * trigger JsClasses error
     *
     * @param string $error_msg
     * @param integer $error_type
     */
    public function trigger_error($error_msg, $error_type = E_USER_WARNING) {
        trigger_error("JsClasses error: $error_msg", $error_type);
    }


/**#@+
 * Setters
 */	
    /**
     * Set classes path array
     *
     * @param mixed $classesPath
     */
    public function  setClassesPath($classesPath) {
    	$this->_classesPath = (array)$classesPath;
    }
    
    /**
     * Set classes cache file
     *
     * @param string $classesCacheFile
     */
    public function setClassesCacheDir($classesCacheDir) {
    	$this->_classesCacheDir = preg_replace('@/$@','',$classesCacheDir);
    }
	
    /**
     * Set JavaScript Files Directory
     *
     * @param string $jsFilesDir
     */
    public function setJsFilesDir($jsFilesDir) {
    	$this->_jsFilesDir = preg_replace('@/$@','',$jsFilesDir);
    }

    /**
     * Set JavaScript Result FileName
     *
     * @since available since Release 1.1
     * @param string $jsFileName
     */
    public function setJsRewriteFileName($jsRewriteFileName) {
    	$this->_jsRewriteFileName = $jsRewriteFileName;
    }
    
    /**
     * Set check last modify class time mode
     * by default is true
     *
     * @param boolean $checkClassModifyTime
     */
    public function setCheckClassModifyTime($checkClassModifyTime) {
    	$this->_checkClassModifyTime = $checkClassModifyTime;
    }

    /**
     * On/Off Compress Mode
     *
     * @param boolean $compressMode
     */
    public function setCompressMode($compressMode) {
    	$this->_compressMode = $compressMode;
    }
    
    /**
     * On/Off gzip result JavaScript Classes File
     *
     * @param boolean $gzipFileMode
     */
    public function setGzipFileMode($gzipFileMode) {
    	$this->_gzipFileMode = $gzipFileMode;
    }
    
/**#@-*/	
	
	/**
	 * Get path for Real Cache JavaScript file
	 * from root web-server directory
	 *
	 * @return string
	 */
	public function _getJsRealFileName() {
		return $this->_jsFilesDir . '/' .$this->_jsRewriteFileName . '.' . $this->_jsRewriteFileExt ;
	}
    
    /** 
     * Get Cache Classes FileName
     * @return string
     */
    private function _getClassesCacheFileName() {
    	return $this->_classesCacheDir . '/' . $this->_jsRewriteFileName . '.' . $this->_classesCacheExt;
    }
	
	/**
	 * Requred Classes for JS-file building
	 *
	 * @param array $classes
	 */
	private function _parseClasses(&$classes) {
		foreach ($classes as &$class) {
			$this->_classes[$class] = true; 
		}
	}
	
	
	/**
	 * Check is Classes is now in JS Cache
	 *
	 */
	private function _checkClasses() {
		foreach (array_keys($this->_classes) as $class) {
			$this->_checkClass($class);
		}
	}
	
	private function _checkClass($class) {
		if (is_array($this->_classesCacheData[$class])) {
		 	if ($this->_checkClassModifyTime 
		 	    and (!$this->_classesCacheData[$class]['checked'])
		 	) {
			 	$lastModifyTime = filemtime($this->_classesCacheData[$class]['classPath']);
			 	if ($lastModifyTime > $this->_classesCacheData[$class]['lastModifyTime']) {
			 		$this->_loadClassCache($class,$lastModifyTime);
			 	}
		 	}
		} else {
			$this->_loadClassCache($class);
		}
		$this->_classesCacheData[$class]['checked'] = true;
		foreach ($this->_classesCacheData[$class]['perentClasses'] as $parentClass) {
			if ($class == $parentClass) {
				$this->trigger_error("Class $class self required?");
			}
			if (!$this->_classesCacheData[$parentClass]['checked']) {
		 		$this->_checkClass($parentClass);
			}
		}
	}
	
	private function _loadClassCache($className,$lastModifyTime = 0) {
		$classPath = $this->_getClassPath($className);
		if (!$lastModifyTime) {
			$lastModifyTime = filemtime($classPath);
		}
		preg_match_all("/\@requires\s+(\w+)/", file_get_contents($classPath), $requiredClasses);
		$perentClasses = $requiredClasses[1];
		$this->_insertClassCacheDataRecord(
			$className,
			$classPath,
			$lastModifyTime,
			$perentClasses
		);
		$this->_isCacheRebuildRequired = true;
	}
	
	/**
	 * Load JS Classes Cache
	 * ������ ���-�����: ���_������:����:�����_�����������:��_�����_�������_�������_�����_�������
	 */
	private function _loadClassesCache() {
		$classesCacheFile = $this->_getClassesCacheFileName();
		if (is_file($classesCacheFile)) {
			$classRecords = preg_split('/\n/', file_get_contents($classesCacheFile));
			$this->_jsFileName = array_shift($classRecords);
			array_pop($classRecords);
			foreach ($classRecords as $classRecord) {
				$classRecordArray = preg_split('/\:/',$classRecord);
				$this->_insertClassCacheDataRecord(
					$classRecordArray[0], // $className
					$classRecordArray[1], // $classPath
					$classRecordArray[2], // $lastModifyTime
					$classRecordArray[3]  // $perentClasses
				);
			}
		}
	}
	
	/**
	 * Insert Class Info into _classesCacheData array  
	 *
	 * @param string $className
	 * @param string $classPath
	 * @param path   $lastModifyTime
	 * @param array  $perentClasses
	 */
	private function _insertClassCacheDataRecord($className,$classPath,$lastModifyTime,$perentClasses) {
		if (!is_array($perentClasses)) {
			if (strlen($perentClasses)) {
				$perentClasses = split(',',$perentClasses);
			} else {
				$perentClasses = array();
			}
		}
		$this->_classesCacheData[$className] = array(
			'classPath' => $classPath,
			'lastModifyTime' => $lastModifyTime,
			'perentClasses' => $perentClasses,
			'checked' => false
		);
	}

	
	/**
	 * Make classPatch for $className
	 *
	 * @param string $className
	 */
	private function _getClassPath($className) {
		$classFileName = preg_replace('/_/','/',$className) . '.js';
		foreach ($this->_classesPath as $classPath) {
			$filename = $classPath . '/' . $classFileName;
			//print $filename;
			if (is_file($filename)) {
				return  $filename;
			}
		}
		$this->trigger_error('Class '.$className.' is not in classes path ['.join(',',$this->_classesPath).'] !',E_USER_ERROR);
	}
	
	
	/**
	 * Generate JavaScript FileName of classes
	 *
	 * @return string
	 */
	private function _generateJsFileName() {
		$maxTime = 0;
		foreach ($this->_classesCacheData as &$dataClassRecord) {
			if ($maxTime <= $dataClassRecord['lastModifyTime']) {
				$maxTime = $dataClassRecord['lastModifyTime'];
			}
		}
		$classesCacheDataKeys = array_keys($this->_classesCacheData);
		sort($classesCacheDataKeys);
		$fileName = join('',$classesCacheDataKeys) . $maxTime;
		$this->_isCacheRebuildRequired = false;
		$this->_jsFileName = $this->_jsRewriteFileName . '_' . crc32($fileName) . '_' . $maxTime . '.' . $this->_jsRewriteFileExt;
	}
	
	/**
	 * Generate JavaScript Classes list cache-file
	 *
	 */
	private function _generateJSCache() {
		$classesCacheFile = $this->_getClassesCacheFileName();
		if (!$classesCacheFileHandle = fopen($classesCacheFile,'w')) {
			$this->trigger_error("File ".$classesCacheFile." is not writable", E_USER_ERROR); 
		}
		
		if (flock($classesCacheFileHandle, LOCK_EX)) { // Exclusive lock
			fwrite($classesCacheFileHandle,$this->_jsFileName."\n");
			foreach ($this->_classesCacheData as $className => &$class) {
				fwrite($classesCacheFileHandle, 
					$className . ':' . $class['classPath'] . ':' . $class['lastModifyTime']	. ':' 
					. join(',', $class['perentClasses']) . "\n"
				);
			}
		    flock($classesCacheFileHandle, LOCK_UN); // Unlock
		}
	}
	
	/**
	 * Build All-In-One JavaScript classes file
	 *
	 */
	private function _generateJsFile() {
		$jsFilePath = $this->_getJsRealFileName();
		if (!$jsFilePathHandle = fopen($jsFilePath,'w')) {
			$this->trigger_error("File ".$jsFilePath." is not writable", E_USER_ERROR); 
		}
		if (flock($jsFilePathHandle, LOCK_EX)) { // Exclusive lock
			foreach ($this->_generateJsClassesQueue() as $className) {
				$content = file_get_contents($this->_classesCacheData[$className]['classPath']);
				fwrite($jsFilePathHandle, $content."\n");
			}
		    flock($jsFilePathHandle, LOCK_UN); // Unlock
		}
		fclose($jsFilePathHandle);
	}
	
	
	/**
	 * Generate JavaScript classes queue 
	 * for JavaScript classes file
	 *
	 * @return array of classes queue
	 */
	private function _generateJsClassesQueue() {
		$classList = array();
		$classQueue = array();
		foreach ($this->_classesCacheData as $className => &$class) {
			if (count($class['perentClasses'])) {
				$classList[$className] = true;
			} else {
				array_push($classQueue,$className);
			}
		}
		
		$eternalCycle = false;
		while (count($classList) and (!$eternalCycle)) {
			$eternalCycle = true;
			foreach (array_keys($classList) as $className) {
				$allowPushToClassQueue = true;
				#print "$className\n";
				foreach ($this->_classesCacheData[$className]['perentClasses'] as $parentClass) {
					#print "$className => $parentClass\n";
					if (isset($classList[$parentClass])) {
						$allowPushToClassQueue = false;
					}
				}
				if ($allowPushToClassQueue) {
					array_push($classQueue,$className);
					unset($classList[$className]);
					$eternalCycle = false;
				}
			}
		}
		if (count($classList)) {
			$this->trigger_error('Class \'@requires\' cycled for classes: '.join(', ',array_keys($classList)), E_USER_ERROR);		
		}
		return $classQueue;
	}

	
	/**
	 * Compress (minimize) JavaScript File 
	 * by JSMin 1.1.1 http://code.google.com/p/jsmin-php/
	 *
	 */
	private function _makeCompressedJsFile() {
		$jsFilePath = $this->_getJsRealFileName();
		$jsFileCompressedPath = $jsFilePath . 'Compressed';
		
		if (!$jsFilePathHandle = fopen($jsFileCompressedPath,'w')) {
			$this->trigger_error("File ".$jsFileCompressedPath." is not writable", E_USER_ERROR); 
		}
		require_once 'JSMin.php';
		fwrite($jsFilePathHandle, JSMin::minify(file_get_contents($jsFilePath)));
		fclose($jsFilePathHandle);
		unlink($jsFilePath);
		if (!rename($jsFileCompressedPath,$jsFilePath)) {
			$this->trigger_error("File '$jsFileCompressedPath' error rename to '$jsFilePath'!", E_USER_ERROR);
		}
	}
	
	/**
	 * Make Gzip JavaScript File
	 * with .js.gz extension
	 *
	 */
	private function _gzipJsFile() {
		$jsFilePath = $this->_getJsRealFileName();
		$jsFileGzipPath = $jsFilePath . '.gz';
		
		if (!$jsFilePathHandle = gzopen($jsFileGzipPath,'w')) {
			$this->trigger_error("File ".$jsFileGzipPath." is not writable", E_USER_ERROR); 
		}
		
		gzwrite($jsFilePathHandle, file_get_contents($jsFilePath));
		gzclose($jsFilePathHandle);
	}
	
}
?>